1. issue
To incentivize the creation of more secure wallets in their team, someone has deployed a registry of Gnosis Safe wallets. When someone in the team deploys and registers a wallet, they will earn 10 DVT tokens.
To make sure everything is safe and sound, the registry tightly integrates with the legitimate Gnosis Safe Proxy Factory, and has some additional safety checks.
Currently there are four people registered as beneficiaries: Alice, Bob, Charlie and David. The registry has 40 DVT tokens in balance to be distributed among them.
Your goal is to take all funds from the registry. In a single transaction.
目标:在单笔交易中,将 注册表中的资金全部掏空
2. analysing
📌 emmm,对我这个菜鸡来说,这道题难度也是相当炸裂的。在做这道题的时候我把
GnosisSafeProxy
和GnosisSafe
这一系列合约都看了,把代码的逻辑弄懂,回过头来才勉勉强强可以理解题目的用意。这道题给我带来巨大的震撼,题目代码越往深处想,带来的震撼就越大。出题人真的太牛了。
2.1 request
让我们先看 challenge.js
中的要求:
1 | after(async function () { |
解读:
- 第一句断言要求玩家
player
只能进行一笔交易;- 第二句断言要求
users[i]
的钱包不能为空,可以理解为user
成功执行了wallets[walletOwner] = walletAddress;
- 第三句断言要求该
user
不再是受益者,及该用户完成了注册,成功执行了beneficiaries[walletOwner] = false;
- 第四句断言要求玩家拿到注册表中的全部代币。
2.2 WalletRegistry.sol
注册合约通读一遍可以看到涉及到转钱的操作只有proxyCreated
函数。仔细分析该合约。
1 | function proxyCreated(GnosisSafeProxy proxy, address singleton, bytes calldata initializer, uint256) |
易知,要执行 SafeTransferLib.safeTransfer(address(token), walletAddress, PAYMENT_AMOUNT)
,则必须要通过前面七个断言。
逐一分析断言:
if (token.balanceOf(address(this)) < PAYMENT_AMOUNT)
:这个不是我们考虑的,金额由题目控制。
if (msg.sender != walletFactory)
:要求调用者为GnosisSafeProxyFactory
合约,分析GnosisSafeProxyFactory
合约不难知道,其中有一个函数createProxyWithCallback
调用了proxyCreated
函数,且注册表是IProxyCreationCallback
的实现类,所以通过调用createProxyWithCallback
即可通过第二个断言。
if (singleton != masterCopy)
,在调用createProxyWithCallback
函数的时候传入与masterCopy
相同的值。
if (bytes4(initializer[:4]) != GnosisSafe.setup.selector)
:可以自己包装这个值,只要该值的前4bytes
与setup
的选择器相同即可。
GnosisSafe(walletAddress).getThreshold() != EXPECTED_THRESHOLD
:这个很离谱,没有对代理合约有一定的了解的话很难理解到这里如何通过,常规思维GnosisSafe
在初始化的时候就已经将threshold
的值设置为1
,此时可以通过这个断言,实则不然,walletAddress
是一个代理合约,返回的是代理合约的threshold
的值,其值未被初始化结果是0
,按理来说这个断言无法通过才对。但是回想上一个断言,调用了setup
函数,里面又调用了好几个方法,其中的setupOwners
函数就很有说法,仔细分析setupOwners
函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 function setupOwners(address[] memory _owners, uint256 _threshold) internal {
require(threshold == 0, "GS200");
require(_threshold <= _owners.length, "GS201");
require(_threshold >= 1, "GS202");
address currentOwner = SENTINEL_OWNERS;
for (uint256 i = 0; i < _owners.length; i++) {
address owner = _owners[i];
require(owner != address(0) && owner != SENTINEL_OWNERS && owner != address(this) && currentOwner != owner, "GS203");
require(owners[owner] == address(0), "GS204");
owners[currentOwner] = owner;
currentOwner = owner;
}
owners[currentOwner] = SENTINEL_OWNERS;
ownerCount = _owners.length;
threshold = _threshold;
}这个函数本身在
GnosisSafe
合约内,但是GnosisSafe
在初始化的时候,已经将threshold
的值设置为了1
,我第一次阅读源码的时候,怎么也搞不懂这个函数的意义是什么,自己又不允许自己用,简直就是画蛇添足。直到我读了一天的题目,才明白这是解题的一个关键点,我是通过代理合约进到此函数,且在代理合约中没有该变量,所以threshold
的值默认是0
,这就为我进行下一步(通过剩下的断言)奠定了基础,看到最后一行,更新threshold
的值,在代理合约中threshold
的值默认是0
,通过代理合约调用getThreshold()
函数,其值也是0
,但是只要我们在这里修改这个值,同时也是在修改代理合约中threshold
的值。将其修改为1
,这样就可通过此断言。
if (owners.length != EXPECTED_OWNERS_COUNT)
:要求传入的数组长度为1
,简单,依ta。
if (!beneficiaries[walletOwner])
:综合上一个断言,直接将题目授权的4个用户,进行遍历,挨个执行该函数即可。
if (fallbackManager != _getFallbackManager(walletAddress)
:至于最后一个断言嘛,我不是很懂,但我的理解是,只有walletAddress
存储的storage
变量别多的离谱大的离谱就行,不超过uint256(keccak256("fallback_manager.handler.address"))
即可。
到此,我已经有办法逐一突破断言,现在要考虑的是,SafeTransferLib.safeTransfer(address(token), walletAddress, PAYMENT_AMOUNT)
,这行代码将 ERC20
代币转入到代理合约
账户里,我们知道代理合约中,没什么函数,ta都是通过逻辑合约实现功能,且ta是通过 degatecall
执行函数调用的,这样一来,msg.sender
永远不可能是ta自己。当然这是昨天的我的认知,被这个点折磨了两天半,通过实践我才发现。
📌在多重
delegatacall
和call
结合使用的时候,如果最后一个调用的方式为call
,那么,整条调用链的msg.sender
将会发生改变,对于最后一个合约来说,ta的调用者为Proxy
,即代理合约。这点反正靠我自己想的话,想到我G了我都想不到,所以不懂的就动手。
将上述分析综合起来,就是本题的解法了。
这是最开始的思路分析
1 | /** |
3. solving
3.1 BackDoorHack.sol
1 | // SPDX-License-Identifier: MIT |
3.2 challenge.js
1 | it('Execution', async function () { |
运行结果
解题成功。